Skip to content

[Tools] Add architecture-general ISA resource diff tool - #1015

Open
Phil-amd wants to merge 1 commit into
mainfrom
phil/isa-resource-diff-arch-general
Open

[Tools] Add architecture-general ISA resource diff tool#1015
Phil-amd wants to merge 1 commit into
mainfrom
phil/isa-resource-diff-arch-general

Conversation

@Phil-amd

@Phil-amd Phil-amd commented Aug 17, 2026

Copy link
Copy Markdown
Member

Compare per-kernel register, spill, scratch, and LDS usage between two
FLYDSL_DUMP_IR dump directories or JSON snapshots, to expose resource
changes that functional tests do not surface.

Read register counts from the per-kernel .set <kernel>.num_vgpr and
.num_agpr symbols, which LLVM emits on every AMDGPU target, rather
than the CDNA-only .agpr_count metadata field, and count LDS traffic
under either spelling, since gfx11 renamed ds_read to ds_load. Both are
required for the tool to work outside CDNA.

Report each metric as a value, as not applicable, or as unparsed, and
block a comparison only on the last. Make vgpr the single VGPR-family
trigger, since the metadata total already includes the accumulators on
gfx90a and later. Exit 0, 1, or 2 for no regression, a regression, or an
untrustworthy result; a crash never reports as 1.

Covered by a backend-agnostic test that generates a CDNA and an RDNA dump
shape, and exposed to agents as the isa-resource-diff skill.

@Phil-amd Phil-amd changed the title [Feature] Add architecture-general ISA resource diff tool [WIP] [Feature] Add architecture-general ISA resource diff tool Aug 17, 2026
@Phil-amd
Phil-amd force-pushed the phil/isa-resource-diff-arch-general branch 7 times, most recently from 81873df to 3b3e9e8 Compare August 19, 2026 09:11
@Phil-amd Phil-amd changed the title [WIP] [Feature] Add architecture-general ISA resource diff tool [Tools] Add architecture-general ISA resource diff tool Aug 19, 2026
@Phil-amd
Phil-amd requested a review from coderfeli August 19, 2026 21:40
@Phil-amd Phil-amd self-assigned this Aug 19, 2026
@Phil-amd
Phil-amd requested a review from jli-melchior August 20, 2026 05:03
@Phil-amd
Phil-amd force-pushed the phil/isa-resource-diff-arch-general branch from 3b3e9e8 to ea7fe17 Compare August 24, 2026 01:40
Comment thread tests/unit/test_isa_resource_table.py Outdated
vgpr_count=285,
),
# An RDNA kernel: no `.agpr_count` anywhere, and LDS spelled ds_load/ds_store.
"rdna": dict(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gfx1250 is based on cdna5 arch.

@Phil-amd Phil-amd Aug 24, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done
You're right, and thanks — the label was wrong. Fixed in 8ef12c8.
Rather than relabel it cdna5, I dropped the microarchitecture names from this code entirely, because the parser never branches on the product family. It branches on two ISA properties.

@Phil-amd
Phil-amd force-pushed the phil/isa-resource-diff-arch-general branch 3 times, most recently from 8118d2c to b0f3f3d Compare August 24, 2026 09:52
@coderfeli
coderfeli requested a review from jhinpan August 24, 2026 12:44

@jhinpan jhinpan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes because the new fail-closed exit-code contract has two paths that still return RESULT: OK for inputs the tool cannot safely compare. I reproduced both on head b0f3f3dd: LLVM's normal *-unknown-gfx* target spelling bypasses architecture comparison, and an unparseable *final_isa.s is omitted from a multi-kernel tree as only a warning. The focused unit test, repository checks, Python style check, and legacy-spelling scan otherwise pass.

Comment thread scripts/isa_resource_table.py Outdated
"""Split ``amdgcn-amd-amdhsa--gfx950:sramecc+:xnack+`` into its parts."""
if not target_id:
return Arch()
tail = target_id.rsplit("--", 1)[-1]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] This split only recognizes the abbreviated amdgcn-amd-amdhsa--gfx950 form used by the synthetic test. LLVM also emits target IDs such as amdgcn-amd-amdhsa-unknown-gfx950; for those, parse_target_id() returns processor=None. Because compare_snapshots() rejects an architecture mismatch only when both processors are known, an unknown-gfx942 snapshot compared with unknown-gfx950 currently returns exit 0 / RESULT: OK. Please extract the trailing concrete gfx... processor from both LLVM spellings (and fail closed when it is genuinely unavailable), with a regression test proving a 942/950 mismatch exits 2.

@Phil-amd Phil-amd Aug 24, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in b491815e. Processor now comes from the last - separated field, so both spellings parse.

The guard needed fixing too, otherwise the parse fix alone would not have closed it: with both processors None, processor != processor is false, so it never reached the known check. It now blocks whenever a processor is unidentified and the target IDs differ.

Tests: both spellings, 942 vs 950 → exit 2, gfx11-generic vs gfx12-generic → exit 2.

Comment thread scripts/isa_resource_table.py Outdated

records = parse_isa(chosen)
if not records:
warnings.append(f"{chosen}: not an LLVM AMDHSA dump (no {METADATA_BEGIN}); skipped")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] A discovered *final_isa.s that yields no records is omitted as a warning, so the comparison can become silently partial. I reproduced this with one valid dump plus one empty/truncated final_isa.s on each side: both bad files are skipped and diff returns exit 0 / RESULT: OK for the remaining kernel. That contradicts the stated fail-closed contract. Please record this as a problem (or retain a blocked sentinel entry) so any final-ISA file the tool cannot parse forces exit 2, and cover the mixed valid/invalid tree case.

@Phil-amd Phil-amd Aug 24, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in b491815e. Recorded as a problem instead of a warning, so it reaches exit 2 — and summarize/snapshot fail closed on it too, via Snapshot.trustworthy.

Took the problem over a sentinel entry: a sentinel on both sides yields one blocked record per metric, and on one side it degrades into ONLY IN AFTER.

Test covers the mixed valid/invalid tree, with the healthy half asserted to exit 0 on its own first.

always a count line and a `RESULT:` verdict that matches the exit code exactly.

**Columns marked `*` are regression triggers**; the rest are context. The full
column reference is in `docs/testing_benchmarking_guide.md` §"Compare per-kernel

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This points readers to a Compare per-kernel ISA resources section that is not present in docs/testing_benchmarking_guide.md; this PR only adds the script to that guide's source-file table. Please either add the promised column reference or link to the documentation that actually defines these columns.

@Phil-amd Phil-amd Aug 24, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in b491815e. Dropped the dangling reference and put the column table in the skill itself.

docs/kernel_tuning_guide.md:462 already pointed here for the full column reference, so the two files pointed at each other while it existed in neither. Defining it here makes that pointer true, rather than adding an ISA column reference to a testing guide.

@Phil-amd
Phil-amd force-pushed the phil/isa-resource-diff-arch-general branch from b0f3f3d to b491815 Compare August 24, 2026 23:45
@Phil-amd

Copy link
Copy Markdown
Member Author

@jhinpan @jli-melchior
No further comments, please proceed for approval

@jhinpan jhinpan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The three findings from b0f3f3d are fixed, but the fail-closed contract still has five independently reproduced paths that report trustworthy data or RESULT: OK after losing information. The focused unit tests and repository checks pass on exact head b491815; the inline findings are additional trustworthiness gaps in the new tool.

Comment thread scripts/isa_resource_table.py Outdated
f"architecture differs ({before_arch.processor} vs "
f"{after_arch.processor}); resource counts are not comparable"
)
elif before_arch.target_id != after_arch.target_id:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] Processor equality does not prove the target modes are comparable. I changed only the suffixes to gfx942:xnack+ and gfx942:xnack-; this branch returned exit 0 / RESULT: OK. Removing both target declarations also returns exit 0. These modes can change code generation and resource use, so please normalize the empty-vs-unknown environment spelling but require a nonempty processor and identical feature sets, otherwise exit 2. Add regressions for differing features and two missing targets.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in aa9b7307. Comparability is now decided on the parsed processor and feature set instead of the raw target ID string, so the environment field normalizes away and xnack/sramecc no longer slip through. Features are sorted on parse, since LLVM's ordering is not a contract.

Both halves confirmed: gfx942:xnack+ vs gfx942:xnack- → exit 2, xnack+ vs sramecc+:xnack+ → exit 2, no target directive on either side → exit 2, and amdgcn-amd-amdhsa--gfx942 vs amdgcn-amd-amdhsa-unknown-gfx942 stays exit 0.

One consequence worth naming: requiring a nonempty processor also refuses gfx11-generic against itself, since RE_PROC does not recognize it. That is the right default — without a processor the scratch/spill applicability is undecidable — and if generic targets ever matter here the fix is to teach RE_PROC about them, not to loosen the verdict. Pinned in the tests either way.

records = {}
for entry in scan.entries:
name, name_problems = _kernel_name(entry)
if not name:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] This silently drops a metadata entry with neither .name nor .symbol; the resulting snapshot still has trustworthy=True. records[name] = ... also lets a later duplicate name overwrite an earlier record, so a stale duplicate can hide a changed counter. Treat missing and duplicate kernel identities as snapshot problems that force exit 2, and cover both mutations.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in aa9b7307. Both are now snapshot problems.

The identity-less case was a plain bug: _kernel_name() already returned "metadata entry has neither .name nor .symbol" and the caller discarded it before continue. parse_isa() now returns (records, file problems) so there is a place for a fault that belongs to the file rather than to a record; collect() folds them into Snapshot.problems.

Duplicates keep the first entry rather than the last and record the collision, so a stale duplicate can no longer overwrite a changed counter silently.

Both regressions splice a second metadata entry into an otherwise healthy file — a single-entry file already raises on losing its only kernel, which hides the case you reported.

Comment thread scripts/isa_resource_table.py Outdated
if raw is None:
return Cell.unparsed(f"metadata field .{metric.field} is absent")
try:
return Cell.of(int(raw))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] Resource counters are non-negative, but int(raw) accepts impossible values. Changing .vgpr_count from 285 to -1 was classified as an improvement and returned exit 0. Validate the metric domain and make any negative resource count untrustworthy instead of comparable.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in aa9b7307. Negative values are rejected in both _metadata_cell() and _symbol_cell(), which makes the metric unparsed and forces exit 2.

Agreed this was the worst of the five: every metric here is a count or a byte size, so a negative one means the dump is malformed, and rendering the drop toward it as an improvement is the one verdict the tool must never invent. Regression covers .vgpr_count: -1 and a negative .set symbol.

Comment thread scripts/isa_resource_table.py Outdated
path = Path(path)
# Assembly is ASCII in practice. Replacing a stray byte degrades one instruction
# count instead of aborting the run with a traceback and a misleading exit code.
text = path.read_text(encoding="utf-8", errors="replace")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] Replacement decoding can silently erase an instruction mnemonic. Corrupting one byte in one ds_read changed the reported count from 3 to 2 while the snapshot remained trustworthy with no problems. Decode strictly, or detect replacement characters and force exit 2, rather than publishing partial instruction counts.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in aa9b7307. Kept the lenient decode — a traceback reports worse than exit 2 does — but the file is now decoded strictly first, and a UnicodeDecodeError is recorded as a file problem before falling back to errors="replace". So the replacement is admitted rather than counted.

Went with strict-then-fall-back over scanning for U+FFFD, which would also fire on a file that legitimately contains that character. Regression corrupts one byte inside a ds_read mnemonic and asserts both halves: the count really does drop to 2, and the snapshot is no longer trustworthy.

if not isinstance(value, int) or isinstance(value, bool):
raise SnapshotError(f"{where}.value must be an integer")
return Cell.of(value)
if state in (NA, UNPARSED):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] Schema v2 accepts n/a for every metric. Setting mandatory vgpr to n/a on both JSON inputs bypasses comparison and returns exit 0. Validate state applicability by metric/architecture: mandatory register, spill, scratch-byte, and static-LDS counters cannot be n/a; reserve it only for quantities that genuinely do not exist on that target.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining this one, unlike the other four.

n/a is only ever produced by _instruction_cells(), for scratch_store/scratch_load on a target that spills through buffer_*. No path in this tool — or in LLVM — emits n/a for vgpr, and Cell.from_json() already validates the state enum, the value type and the reason type. Reaching the case you describe means hand-editing the tool's own serialized output, which is not a trustworthiness gap in the data path the way the other four are: those all start from a file LLVM or the filesystem produced.

The prescribed fix also has a cost I would rather not pay. "Validate state applicability by metric/architecture" puts a second copy of the applicability rules in the loader, so spills_via_scratch() and the JSON validator would have to be kept in agreement forever, and the first time they drift the tool refuses a snapshot it wrote itself.

Happy to reconsider if you can show a snapshot the tool actually produces that carries n/a on a mandatory metric, or a second producer of this schema — either would move it into the same class as the rest.

@Phil-amd
Phil-amd force-pushed the phil/isa-resource-diff-arch-general branch from b491815 to aa9b730 Compare August 26, 2026 07:53
@Phil-amd

Phil-amd commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

@jhinpan
Continue review when you have time.

@Phil-amd
Phil-amd force-pushed the phil/isa-resource-diff-arch-general branch from aa9b730 to a3e74b4 Compare August 27, 2026 21:13

@jhinpan jhinpan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed current head a3e74b4 in three passes (parser/data integrity; CLI/schema/adversarial inputs; tests/docs/CI). The focused unit suite passes 9/9, and check_repo plus Python style both pass. The Navi CI failure is an unrelated runner OOM (only 122 MiB free). However, the reusable JSON path still violates the fail-closed contract: the previously reported case where both snapshots mark a mandatory metric as n/a still returns RESULT: OK, and Cell.from_json() also accepts negative resource values, so a 10 -> -1 VGPR change is reported as an improvement with exit 0. Please make snapshot loading validate metric state/domain and add CLI-level JSON regressions for both cases. No GPU run was needed because this PR is compile-only tooling and both failures reproduce deterministically through its CLI.

value = raw.get("value")
if not isinstance(value, int) or isinstance(value, bool):
raise SnapshotError(f"{where}.value must be an integer")
return Cell.of(value)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] The assembly parser now rejects negative counters, but the reusable JSON decoder still accepts them. I reproduced a schema-v2 snapshot with vgpr=-1: load_snapshot() marked it trustworthy and diffing 10 -> -1 returned exit 0 / RESULT: OK. Please reject negative VALUE cells during snapshot loading (and add a CLI-level JSON regression). This is the same fail-closed boundary as the still-unresolved mandatory-n/a case, where two snapshots both declaring vgpr as n/a also return OK.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split verdict on the two halves.

Negative VALUE cells on load — accepted, fixed in 7eff378a. You are right that this was an asymmetry I introduced: I put the domain check in _metadata_cell()/_symbol_cell() and left Cell.from_json() without it, for the same Cell type and the same invariant. Non-negativity belongs to the metric, not to the parser that happened to read it. Cell.from_json() now raises SnapshotError on a negative value, consistent with how it already rejects a wrong type there.

CLI-level regression added as asked: diff ten.json negative.json → exit 2, with a healthy JSON pair asserted at exit 0 first so the 2 is attributable. Mutation-checked — disabling the new branch fails exactly that test.

Mandatory n/a — still declining, for the reason given last round, which this review does not address. The distinction is domain versus applicability. Non-negativity is intrinsic to the value and is one comparison in the place the value is constructed, which is why I took it. Which metrics may be n/a is a property of the target, derived by _instruction_cells() from spills_via_scratch(); asserting it in the loader means a second copy of that rule, and the first time the two drift the tool refuses a snapshot it wrote itself.

I asked for one of two things to move it into the same class as the rest: a snapshot the tool actually produces that carries n/a on a mandatory metric, or a second producer of this schema. Re-running the same hand-edited input is not either of those — Cell.na() is called in exactly one place, for scratch_store/scratch_load, and no path in this tool or in LLVM writes n/a for vgpr. If you have a concrete producer in mind I will take it.

Unrelated but worth flagging: this review was against a3e74b4, which was a force-push of the same commit onto a newer main from another machine. I rebased onto it rather than over it, so nothing from that push was dropped — the only delta from a3e74b4 is the fix above.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closing my half of this thread: the mandatory-n/a finding is withdrawn.

I checked it the way I should have in round 3 instead of restating it. Cell.na has exactly two construction sites in the file, lines 540-541, both scratch_store/scratch_load under spills_via_scratch() is False. There is no third, so no dump LLVM emits and no path through parse_isa can put n/a on a mandatory metric -- reaching it means editing the tool's own serialized output by hand. That is the distinction you drew between this and the other four, and it holds.

The negative-VALUE half is verified fixed on 7eff378a, independently of your test: healthy JSON pair -> exit 0, 10 -> -1 -> exit 2, with the error naming the cell (k::gemm_0.vgpr.value is negative: -1).

Compare per-kernel register, spill, scratch, and LDS usage between two
FLYDSL_DUMP_IR dump directories or JSON snapshots, exposing resource
regressions that functional tests do not surface.

To work outside CDNA, read register counts from the per-kernel
`.set <kernel>.num_vgpr`/`.num_agpr` symbols that LLVM emits on every AMDGPU
target, rather than the CDNA-only `.agpr_count` metadata field, and count LDS
traffic under both the `ds_read` and the gfx11+ `ds_load` spelling. Take the
processor and the feature set from the target ID, normalizing the triple's
environment field, which is spelled either empty or `unknown` for one and the
same target.

Report each metric as a value, as not applicable, or as unparsed, and exit 0,
1, or 2 for no regression, a regression, or an untrustworthy result. Fail
closed on anything that would otherwise answer from a partial comparison: an
unparsed or impossible metric, a dump file that does not parse or decode, a
kernel entry with no identity or a duplicated one, and two sides whose targets
are not provably the same. Covered by a backend-agnostic test over all three
parser axes and exposed to agents as the `isa-resource-diff` skill.
@Phil-amd
Phil-amd force-pushed the phil/isa-resource-diff-arch-general branch from a3e74b4 to 7eff378 Compare August 29, 2026 06:23
@Phil-amd

Copy link
Copy Markdown
Member Author

@jhinpan
Continue review when you have time.

@jhinpan jhinpan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 4 of review on head 7eff378a. This pass was the data path, using two evidence sources I had not used in rounds 1-3: the 61 real FLYDSL_DUMP_IR dumps on my machine, and LLVM's own metadata emission rather than my reading of the format.

Verified fixed. Cell.from_json rejects a negative VALUE: a healthy JSON pair still compares at exit 0, and diff ten.json negative.json is exit 2. Reproduced independently of your test.

Withdrawing the mandatory-n/a half -- you were right, and I carried it one round too long. I traced every construction site of Cell.na in the file; there are exactly two, both at lines 540-541, both scratch_store/scratch_load under spills_via_scratch() is False. No parser path and no LLVM output can put n/a on a mandatory metric, so the case is reachable only by hand-editing the tool's own output. That is a different class from the four defects that start at a file LLVM or the filesystem produced, and your point about a second copy of the applicability rules in the loader stands on its own. Dropping it; it should not have survived round 3.

Real dumps. All 61 *final_isa.s files on this box parse clean -- gfx950, both target-ID spellings (37 --gfx950, 24 -unknown-gfx950), MFMA and non-MFMA kernels. Spot-checked the instruction counting against the assembly: the megamoe tree reports matrix_ops 64 and 8 for the two stages, and grep -c v_mfma_scale_f32_16x16x128_f8f6f4 over that tree is 72. ds_swizzle_b32 and ds_bpermute_b32 (74 sites) are correctly excluded from LDS traffic. Two real gfx950 trees whose kernels were renamed by a config change land on four ONLY IN rows and exit 2 rather than a partial comparison. JSON round-trip is faithful on real data: summarize --json then diff dir snapshot.json gives 4 of 4 compared, 0 changed, exit 0.

One new blocking finding, inline -- a false negative in the tool's core purpose, on a target this PR claims to cover.

Repository checks on exact head 7eff378a: focused unit suite 9/9 -> now 10/10, scripts/check_python_style.sh clean, scripts/check_repo.py 2/2. CI: mi325, mi355 and navi all pass; test (linux-flydsl-mi35x-1) is cancelled after 51h41m with zero recorded steps -- a hung runner, unrelated to this change.

# two regressions, and would flag moving accumulators into AGPRs -- which the kernel
# tuning guide recommends -- as a regression.
METRICS = (
Metric("vgpr", TRIGGER, METADATA, "vgpr_count"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] vgpr is the only VGPR-family trigger, and that is sound only where .vgpr_count is the arch+accumulator sum. LLVM makes it the sum only on gfx90a+:

// AMDGPUMCExpr::evaluateTotalNumVGPR
bool Has90AInsts = AMDGPU::isGFX90A(STI);
uint64_t TotalNum = Has90AInsts && NumAGPR ? alignTo(NumVGPR, 4) + NumAGPR
                                           : std::max(NumVGPR, NumAGPR);

isGFX90A tests FeatureGFX90AInsts. gfx908 (FeatureISAVersion9_0_8) is MFMA-capable -- it emits .agpr_count, which is gated on STM.hasMAIInsts() -- but does not carry that feature. So on gfx908 .vgpr_count is max(arch, agpr), not the sum.

Reproduced on head 7eff378a with a gfx908 dump whose .vgpr_count is exactly what LLVM would emit: arch_vgpr 64 on both sides, agpr 10 -> 60, so max(64,10) == max(64,60) == 64.

kernel    *vgpr arch_vgpr        agpr *sgpr ... *vgpr_spill ...
k::gemm_0    64        64 10->60(+50)    59 ...           0 ...

compared 1 of 1 kernels; 0 unchanged; 1 changed; worsened: 0; improved: 0
RESULT: OK          # exit 0

Fifty accumulator registers on a part whose 256-entry AGPR file is a separate occupancy limiter, reported as no regression. The tool has already parsed the number and prints it in the row -- it simply is not a trigger, and agpr is INFO.

docs/kernel_tuning_guide.md in this same PR states the precondition correctly ("targets with a unified register file (gfx90a and later MFMA-capable parts)"). The code applies the conclusion unconditionally, so the one target where the premise fails is the one target where the premise was worth checking.

Please make it decidable from the target rather than assumed -- either promote agpr to a trigger when the target is MFMA-capable but not unified-RF, or make vgpr unparsed (exit 2) when vgpr < arch_vgpr + agpr on a target this tool cannot prove unified. A cross-check is the cheaper of the two and matches what _cross_check_scratch already does for the other quantity that has two sources.

Worth a regression shape either way: TARGETS covers unified-RF-with-AGPRs (gfx942) and no-AGPR (gfx1250), but not has-AGPRs-without-unified-RF, which is exactly where this lives.

@jhinpan jhinpan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 5 on head 7eff378a. This pass was the schema and normalization boundaries -- the JSON path, the .set key space, and the v1 branch -- looking specifically for invariants that hold on one of the two paths that build the same object, since that is the shape of the defect you fixed in 7eff378a.

Four findings, all inline, none blocking. Every one of them fails closed, so none can produce a wrong verdict; two produce a false exit 2, and two lose information a caller would want. I would take all four, but none of them should hold the PR.

The pattern worth naming across three of them: Cell, Arch and Snapshot each have an assembly constructor and a JSON constructor, and the checks have been added to the assembly one as they were found. Cell reached parity in 7eff378a. Arch has not (feature sorting), and Snapshot has not (per-record problems are restored but never folded in). If you want one change rather than four, making the JSON constructors route through the same normalization as the parse path would close the class rather than these two instances.

No new findings on the comparison core. I re-checked _match_keys drift pairing, the total_kernels arithmetic against a real 6-kernel two-sided tree, the matched_after consumption of the right-hand side of a drift pair, and DiffRow.changed/delta for the n/a-vs-n/a and UNPARSED-precedence cases; all behave as documented.

features = raw.get("features") or ()
if not isinstance(features, (list, tuple)) or any(not isinstance(f, str) for f in features):
raise SnapshotError("arch.features must be an array of strings")
return Arch(str(raw.get("target_id") or ""), processor, gen, tuple(features))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[non-blocking] parse_target_id sorts the feature set "because the feature set is what matters and LLVM's order is not a contract", and Arch's own field comment states the invariant ("sorted, so two spellings of one set compare equal"). Arch.from_json does not apply it -- it takes tuple(features) verbatim, so the invariant holds on the assembly path and not on the snapshot path:

parse_target_id("...gfx942:xnack+:sramecc+").features  -> ('sramecc+', 'xnack+')
Arch.from_json({... "features": ["sramecc+","xnack+"]}) -> ('sramecc+', 'xnack+')
Arch.from_json({... "features": ["xnack+","sramecc+"]}) -> ('xnack+', 'sramecc+')

_target_blockers(first, second)
-> ('target features differ (sramecc+:xnack+ vs xnack+:sramecc+); they change code
    generation, so this diff would measure the build flags rather than the change
    under test',)

Fail-closed, so no wrong verdict -- but it is a false exit 2 between two snapshots the tool would call identical had it re-parsed the target ID, and it is the same class of "one normalization, applied on one of the two paths that build the object" as the negative-value asymmetry you fixed in 7eff378a. tuple(sorted(features)) here is the call parse_target_id already makes.

# 90 of 233 real dumps emit `.set .L<kernel>.num_vgpr` even for .globl kernels,
# because the local-linkage test differs from the one behind metadata `.name`.
base = base[2:]
symbols.setdefault(base, {})[suffix] = value

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[non-blocking] Last-wins here, unlike the metadata duplicate path you changed to first-wins-and-record in aa9b7307. Records are keyed by base after the .L prefix is stripped, so the two spellings of one name collide and the later line silently replaces the earlier one with nothing recorded:

.set gemm_0.num_vgpr, 256
.set .Lgemm_0.num_vgpr, 1

-> arch_vgpr = Cell(state='value', value=1)
   file problems = ()

Reachability is low -- it needs one file emitting both spellings for one base name -- but the comment two lines up is what makes it worth closing: both spellings are in circulation (90 of 233 real dumps use the .L form), and this normalization is what puts them in the same key space. The resolution you already picked for duplicate metadata entries applies unchanged: keep the first, record the collision.

private_seg_size is the one with teeth. It is not a displayed metric, it feeds _cross_check_scratch, so a collided value produces a scratch-bytes disagreement that does not exist and takes the run to exit 2 with a wrong reason.

problems=tuple(body.get("problems") or ()),
)

problems = []

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[non-blocking] load_snapshot restores record.problems faithfully at line 873 and then nothing ever reads them. Snapshot.problems gets one generic line instead, so a snapshot carrying a real per-kernel fault round-trips into a comparison strictly less diagnosable than the directory it came from:

record.problems restored:  ("metadata .name 'gemm_0' disagrees with .symbol 'other_name.kd'",)
snapshot.problems surfaced: ('bad.json was written from data the tool could not fully parse',)

The verdict is right (exit 2) and the detail is sitting in the file, which is what makes losing it avoidable: diff dirA dirB names the fault, diff a.json b.json does not, for the same data. Folding the restored per-record problems into Snapshot.problems -- what collect() already does at line 745 -- makes the two input paths report the same thing.

return Snapshot(kernels=kernels, warnings=tuple(warnings), problems=tuple(problems))


_V1_ALIASES = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[non-blocking] This is compatibility code for a format the repository has never shipped. scripts/isa_resource_table.py is new in this PR, SCHEMA_VERSION has been 2 since 3b3e9e8b, and aa6a2d40 is the only commit that ever wrote a v1 snapshot -- it never left this branch, so no v1 file exists outside a working tree that also contains the tool that reads v2.

It also cannot produce a comparable row even if one did. _load_v1 builds KernelRecord(..., arch=Arch()), which _target_blockers refuses as an unidentified target, and the five metrics with no v1 alias come back Cell.unparsed. Diffing a v1 snapshot against itself:

gemm_0: unparsed metric numbered_sgpr: absent from this v1 snapshot
gemm_0: unparsed metric lds_write: absent from this v1 snapshot
gemm_0: unparsed metric matrix_ops: absent from this v1 snapshot
5 kernel(s)/metric(s) not trustworthy (see stderr)
RESULT: NOT TRUSTWORTHY      # exit 2

So the branch, the alias table and the warning are ~35 lines that can only ever reach the verdict the schema check three lines below would reach on its own. Suggest deleting it. If you would rather keep it, the warning should say the snapshot cannot be compared at all rather than "regenerate it with this version of the tool", which reads as though the current run produced a usable answer.

@jhinpan jhinpan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 6 on head 7eff378a: documentation, tests, and CI wiring. Three documentation findings and one test-shape gap, all inline, none blocking.

Verification on exact head 7eff378a, all re-run in a clean worktree at that commit:

check result
pytest tests/unit/test_isa_resource_table.py 10 passed
scripts/check_python_style.sh 2 files unchanged, all checks passed
scripts/check_repo.py 2/2 (typed-arithmetic, agent-docs)
61 real *final_isa.s dumps, summarize + diff parse clean, verdicts correct
CI mi325 / mi355 / navi pass
CI test (linux-flydsl-mi35x-1) cancelled after 51h41m, zero recorded steps -- hung runner, unrelated

The new test is genuinely wired in, not merely runnable: scripts/run_tests.sh line 57 includes tests/unit/, and .github/workflows/flydsl.yaml line 548 runs that script, so the suite executes on every GPU runner rather than only by hand. The l0_backend_agnostic marker is registered in tests/pytest.ini.

The documentation findings are all one shape: a sentence that is true on the common path and states no precondition, so a reader who hits the other path gets a confident wrong reading rather than a caveat. That is the same failure mode the tool itself is built to prevent, which is why they are worth fixing even though none of them changes a verdict.

That closes rounds 4-6. The blocking item is the round-4 gfx908 vgpr trigger; everything from rounds 5 and 6 is non-blocking and can be batched or declined on its merits. As before, if you disagree with any of them, say so with the reasoning -- you have been right once already this review.

RESULT: REGRESSION
```

Only changed and problematic kernels are printed. The last two stdout lines are

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[non-blocking] Both stdout-shape claims are false exactly when there are problems -- the case an agent scripting against this contract most needs to get right. _render_problems prints its count of untrustworthy items to stdout, between the count line and the verdict:

$ isa_resource_table.py diff <two real gfx950 dump trees> 2>/dev/null | tail -3
compared 2 of 6 kernels; 2 unchanged; 0 changed; worsened: 0; improved: 0
4 kernel(s)/metric(s) not trustworthy (see stderr)
RESULT: NOT TRUSTWORTHY

The last two stdout lines are that count and the verdict, not "a count line and a RESULT: verdict". Line 147's "-q/--quiet prints only the verdict line" fails the same way, on the same run:

$ isa_resource_table.py diff <same two trees> -q 2>/dev/null
4 kernel(s)/metric(s) not trustworthy (see stderr)
RESULT: NOT TRUSTWORTHY

Two ways to close it. Narrow both sentences to "the last stdout line is always the RESULT: verdict" -- true unconditionally, and all a script needs. Or route that one line to stderr with the rest of _render_problems, which would make both sentences true as written and match "Warnings on stderr never change the exit code" in Pitfalls.

|---|---|---|---|
| `vgpr` | yes | `.vgpr_count` metadata | Total VGPRs, arch + accumulator — LLVM's own occupancy number |
| `arch_vgpr` | no | `.set` symbol `num_vgpr` | The arch half of that total |
| `agpr` | no | `.set` symbol `num_agpr` | The accumulator half of that total |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[non-blocking] Same boundary as the blocking vgpr finding in round 4. "The accumulator half of that total" holds only where .vgpr_count is the arch+accumulator sum, which LLVM makes it only on gfx90a+; on gfx908 it writes max(arch, agpr), so agpr is not a component of vgpr at all there.

That makes the "Do not add arch_vgpr and agpr to vgpr" note below read as a stronger guarantee than the metadata format gives, and it is the sentence a reader will use to decide that a moved AGPR count needs no attention.

docs/kernel_tuning_guide.md in this same PR already scopes it correctly ("targets with a unified register file (gfx90a and later MFMA-capable parts)"). Carrying that same qualifier into this table -- and into the vgpr row above it -- would make the two documents agree. Whatever the code ends up doing about the trigger, this table should describe the format, not the gfx90a+ special case.

("ds_store", re.compile(r"^ds_store(?:_|2)")),
("scratch_store", re.compile(r"^scratch_store")),
("scratch_load", re.compile(r"^scratch_load")),
("matrix_ops", re.compile(r"^v_(?:mfma|wmma)")),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[non-blocking] matrix_ops misses v_smfmac_*, CDNA3/4's sparse MFMA, so a kernel built on the sparse matrix cores reports matrix_ops 0. On head:

_categorize("v_smfmac_f32_16x16x32_f16")  -> None
_categorize("v_mfma_f32_16x16x16_f16")    -> matrix_ops

Informational column, so no verdict is wrong; the fix is ^v_(?:mfma|wmma|smfmac). Flagging it from the ISA rather than from a failure I hit -- none of the 61 real dumps on this machine use sparse MFMA -- so treat it as a completeness note on the same footing as the ds_read/ds_load spelling pair this tuple already handles. The SKILL.md row for this column says "MFMA / WMMA sites" and would want the same edit.

return "\n".join(lines) + "\n"


TARGETS = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[non-blocking] TARGETS covers both sides of three axes -- .agpr_count present or absent, gfx9 or gfx11+ LDS spelling, empty or unknown environment -- and the file's docstring says so. The axis it does not cover is the one that carries the round-4 blocking finding: whether the target's register file is unified.

The two shapes are gfx942 (unified RF, AGPRs in use, vgpr_count = 256 + 29) and gfx1250 (no AGPRs at all). Both satisfy ".vgpr_count is the arch+accumulator total". gfx908 is the third case -- MFMA-capable, .agpr_count emitted, but no FeatureGFX90AInsts, so LLVM writes max(arch, agpr) -- and on that shape the only VGPR trigger cannot see an AGPR-only change.

A gfx908 entry would be a two-line addition here (arch="gfx908", mfma=True, vgpr_count=max(num_vgpr, num_agpr)) and would fail today against any fix that makes the AGPR change visible, which is what makes it worth pinning rather than describing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants